All-to-All Grouping
先看“全部通信”要求了什么
“既然最终都要发送,越早全部发出去,应该越快。”这个判断在网络、端点和软件进度引擎都有足够资源时完全合理。分组之所以有时更快,是因为真实系统未必能同时高效处理所有请求。
先固定通信语义。设有四个 rank,编号为 0、1、2、3,每个 rank 准备四块数据:一块给自己,其余三块分别给其他 rank。例如 rank 0 的输入是 [x00, x01, x02, x03];通信完成后,rank 2 收到 [x02, x12, x22, x32]。下标的第一位是来源,第二位是目的地。
All-to-all 规定最终的数据归属,没有要求所有传输必须同时发生。这来自 MPI 对块交换的定义;具体实现可以采用直接发送、分轮交换或其他等价算法。本文讨论常见的同一通信域内交换,自身那块可以本地复制,不需要走网络。MPI All-to-All 语义
这也给“分 group”划出第一条边界:
- 只把工作分批:四个 rank 最终仍交换全部目标数据,改变的是调度。
- 把通信域拆成
{0,1}和{2,3},各自独立做 all-to-all:原来跨集合的数据没有送到,已经改变任务。只有应用本来就允许独立通信,或者另有跨组阶段补齐数据,才成立。
后文先讨论第一种:总任务不变,怎样安排它更快完成。
如果把目的概括成“防止接收端阻塞”,更精确的说法是:让接收端只接纳它能够处理的工作,把超额等待留在发送端。这通常能避免无节制注入带来的积压,但并不消灭等待,也不自动解决死锁。
针对这个目标,可以把常见设计思路分成以下四项;前两项放在同一张图中比较。这里是机制分类,不是所有通信库统一采用的算法名称。
| 分组方式 | 分的是什么 | 接收端得到的约束 | 对应图 |
|---|---|---|---|
| Pairwise 轮转 | 按轮次选择一对发送、接收 peer | 一个逻辑轮内,每个 rank 一路入向 | 图 1 |
| 有限并发窗口 | 限制当前在途请求,完成后补充 | 独立本地窗口不自动保证全局接收扇入;需配合收发匹配、错峰或 credit | 图 1 |
| Rank 块轮转 | 固定分组成员,轮转源组与目的组配对 | 组宽为 s 时,一个逻辑轮内每个 rank 最多面对 s 个源 |
图 2 |
| 拓扑分层 | 分开节点间与节点内等不同通信域和数据路径 | 能组织转发与利用快链路,但接收流量上限仍需单独设计 | 图 3 |
Credit 是可以叠加在这些方案上的流控协议,不是第五种固定拓扑。NCCL 调用 group 的含义又不同,后文单独解释。All-to-all 还有 Bruck 等面向小消息、减少通信轮次的算法,不能把所有算法设计都归因于接收拥塞。Open MPI 的算法列表与适用说明
分批为什么可能比齐发更快
活跃请求也要消耗资源
一条传输除了 payload,还可能占用请求描述符、网卡发送队列、接收缓存、软件轮询时间,以及负责搬运和处理完成事件的执行资源。具体占用哪些资源,取决于 MPI、NCCL 或自定义通信内核的实现。
因此,增加并发有两个阶段:资源尚未吃满时,它能填补等待空隙;资源已经饱和后,继续增加请求可能主要增加排队、调度开销和尾延迟。
用一个教学容量模型说明:接收端每个时间单位能处理两块数据,而这一单位突然来了六块,那么四块必须等待。提前把它们全部注入接收路径,不会把服务能力从两块提高到六块。如果额外队列又挤占共享缓存、触发回压或拖慢进度处理,总体完成时间还可能增加。反过来,如果排队没有额外代价,服务端始终满载,仅把等待从接收端搬到发送端,也不必然更快。
Open MPI 提供了可核查的工程例子。其 5.0.7 版本 Alltoall 算法既包含一次启动全部非阻塞收发的 linear,也包含只维持有限在途请求的 linear_sync;后者通过 coll_tuned_alltoall_max_requests 控制窗口。Open MPI 5.0.7 算法说明
这里的直接收益是给活跃请求数量设置上界。它不意味着 communicator 变小、已创建的 QP 被销毁,或者完整输入输出 buffer 随窗口同比缩小。能否减少内存,还取决于实现是否按窗口复用临时存储。
按 peer 轮转与窗口
假设每个 rank 一次只发一个目的地,看起来已经很克制。但如果 rank 1、2、3 都先发给 rank 0,rank 0 仍然同时收到三路流量。每个发送端的扇出小,不代表每个接收端的扇入小。多源同时汇入一个接收端,就是 incast。
错开目的地能进一步限制接收端竞争。下面是四个 rank 的循环平移调度;每个格子只写该源本轮的发送,接收由对应箭头确定。
| 轮次 | rank 0 发送 | rank 1 发送 | rank 2 发送 | rank 3 发送 |
|---|---|---|---|---|
| 1 | 0 → 1 | 1 → 2 | 2 → 3 | 3 → 0 |
| 2 | 0 → 2 | 1 → 3 | 2 → 0 | 3 → 1 |
| 3 | 0 → 3 | 1 → 0 | 2 → 1 | 3 → 2 |
每一轮,每个 rank 恰好发给一个其他 rank,也恰好从一个其他 rank 接收。三轮覆盖全部 12 条非自身有向传输;输入块保留到对应发送完成,接收块写入按来源划分的输出位置,不需要额外复制出三套完整输入。
把同一个 rank 分别画在发送列和接收列,可以直接数出每个目标有几条入边。图 1 为了避免交叉线,把右侧目的 rank 按本轮顺序重排;这不会改变物理接线。
轮转配对控制“谁同时发给谁”,窗口控制“同时保留多少工作”。如果在同一受控阶段合并 p 个不同的循环偏移,每个目标至多有 p 个源;但如果每个源独立推进自己的窗口,快源可能已经进入后续阶段,不能继续只按单个逻辑轮统计接收压力。要得到真实上限,还需防止未获准的阶段叠加,或者用接收额度直接约束在途数据。
这里的“一个逻辑轮”也不等于调用一次全局 barrier。生产实现可以用匹配收发、局部完成和 peer 许可表达必要依赖;关键是这些机制实际保护了哪项资源。
Open MPI 5.0.7 的 ompi_coll_base_alltoall_intra_pairwise 使用相应的目标计算。下面仅摘录目的 rank 计算,省略的收发 buffer 地址、sendrecv 调用和错误处理可见原文件:
1 | sendto = (rank + step) % size; |
其中 rank 是当前编号,size 是通信域大小,step 是当前轮次。实现遍历 1..size,把自身复制放在最后;上表只展示非自身的三轮。固定版本源码,第 189–204 行
按 rank 块轮转
把单 rank 换成一组 rank,也能采用相同思路。例如 64 个 rank 分为四组,每组 16 个;每轮每个源组只面向一个目标组,并让组间配对形成置换。这样目标组这一轮只接收一个源组的流量,但组内每个接收 rank 仍可能面对 16 个源。组间错峰没有消除组内竞争。该例只是调度结构推导,不代表 16 是最优组宽。
为了把组内、组间两个层级画清楚,图 2 缩小成八个 rank、四组,每组两个成员。第 t 轮,源组 g 访问 (g+t) mod 4;t=0 覆盖组内交换,t=1,2,3 覆盖另外三个组,自身数据本地处理。
以 G0={R0,R1} 访问 G1={R2,R3} 为例,允许的传输是 R0→R2、R0→R3、R1→R2、R1→R3。因此 R2 本轮只面对 R0 和 R1,其他源组暂时访问其他目标,或者在源端等待下一次许可。限制接收压力的原因,是本轮目标组只有一个源组进入,而不是每个组里的人彼此不通信。
一般地,P 个 rank 按每组 s 个划成 G=P/s 组,这种模型需要 G 个阶段覆盖全部组配对。其逻辑轮内扇入从全开的最多 P−1 个非自身源,降到最多 s 个;组内阶段排除自身后是 s−1。如果各阶段没有适当的推进约束,这个“每轮”上限仍不能直接当作任意时刻的在途上限。
以上保证都属于逻辑端点层面。多个不同目的地仍可能经过同一条 PCIe 上行链路、网卡或交换机出口,因此还要把 rank 编号映射回物理拓扑。网络路径选择和端点拥塞控制也有不同作用范围,不能只凭端点配对均匀就认定整网无拥塞。NVIDIA 对路径均衡与端点 incast 的区分
快慢不一致时,需要接收许可
表格把每轮画得整齐,真实 rank 的完成时间却不相同。如果快发送端直接进入下一轮,而慢接收端还在处理上一轮,原本错开的流量又可能叠到一起。
一种通用方法是 credit,接收许可:发送端只有拿到对应接收资源的许可,才发下一块;接收方在数据被消费、对应槽位能安全复用后返还许可。尚未获得许可的数据留在发送端,等待被限制在接收资源之外。
这个协议需要说清三个细节:
- 许可按什么计量:每条消息、每个 chunk、字节数,还是某个 group 的一轮工作;变长消息下,“两个请求”不等于固定字节数。
- 许可何时返还:发送方本地完成、数据对端可见、接收方消费完成,是不同事件。若许可保护接收槽位,必须等到它确实能复用。
- 许可覆盖谁:每个 peer 都单独允许两块,面对 32 个 peer 时仍可能积累 64 块。要限制共享接收资源,需要总额度或其他等价约束。
credit 自身增加信号、计数器和依赖;初始额度、完成通知的推进以及循环等待也必须设计正确。它是受控背压机制,不是“加上一个 flag 就绝不会挂住”的保证。
图 2 的 credit 流程可以用一个容量约束检验:设每个等长 chunk 占一个接收槽位,每个接收 rank 总共有 C 个槽位,把已获得额度但尚未发送、正在传输、已经到达但尚未释放的 chunk 分为三个互斥状态,那么它们的总数必须始终 **不超过 C**。没有额度的发送者保留源数据并等待;消费完成、槽位安全释放后才返还额度。这样保护的是接收 buffer 的容量,不是保证网络里没有排队,也不是保证消费者永远不会停顿。
若 chunk 大小不等,应改为字节额度或按最大块大小预留。若每个源各自拿到 C 个额度,而这些额度最终共用同一个只有 C 个槽位的目标 buffer,这个约束就已经被破坏了。
另一种办法是使用可补充的在途窗口。Open MPI 的 linear_sync 在有限数量的收发请求中等待任一请求完成,再补入同类型的新请求;它并不要求每批都执行全局 barrier。窗口上限分别作用于发送和接收,例如上限为四时,可以有四个发送请求加四个接收请求。这是本地请求窗口,不应直接称为接收方 credit 协议。两者都限制活跃工作,但约束的位置不同。linear_sync 源码说明及推进循环
这些机制能帮助理解仓库已有的 MoonEP Group Design:它用交互场景展示容量、错峰与许可,再映射到固定版本实现。具体的 AIV 分工、组员编号和完成协议仍应回到该文的源码证据,通用推导不能证明某个硬件上的最优参数。
“防止阻塞”需要分层理解
到这里,分组缓解拥塞的逻辑已经清楚:控制活跃工作,分散热点,并限制快发送者对慢接收者的冲击。但工程里说的“通信阻塞”,至少有以下几种不同情况。
| 现象 | 实际发生了什么 | 分组能帮助到哪里 |
|---|---|---|
| 拥塞与回压 | 请求到达速度超过链路或端点服务能力,队列增长,发送被减速或暂停 | 并发窗口、错峰和接收许可可能降低瞬时压力 |
| 队头阻塞 | 队首工作暂时不能推进,把本可执行的后续工作也挡住 | 需要独立队列、可绕过调度或更小粒度完成机制;仅编号分组不够 |
| 等待慢 rank | 后续阶段需要的数据尚未全部到齐 | 流水或独立完成可以缩小等待范围;最终需要的那份慢数据仍要等 |
| 死锁 | 存在循环等待,所有相关操作都无法继续 | 必须修复依赖和提交顺序;缩小 group 本身没有保证 |
| API 阻塞 | 调用尚未返回,或主机正在等待 GPU 完成 | 这是接口与完成语义,不能据此单独判断网络拥塞 |
队头阻塞尤其容易被误用。如果一个 FIFO 中,发给慢目标的数据挡住了后面发给快目标的数据,分开队列且调度器能继续服务其他队列,才有机会隔离影响。仅换一个 communicator 名字,底下仍共享同一个 FIFO,就没有自动隔离。
分组还可能增加等待:若实现规定“本组所有 peer 完成后才能开始下一组”,一个慢 peer 会挡住下一组中本可独立执行的工作。按完成事件补充窗口,可以减少这种整批等待;能否采用它,仍取决于匹配顺序、buffer 复用和应用的数据依赖。
网络层也存在这种传播:RoCE 的 PFC 按优先级暂停流量,共享该优先级的流可能一起受影响。应用层分组最多是减少触发拥塞的压力,不能直接等价成网卡队列或 PFC 优先级隔离。NVIDIA 关于 PFC 与队头阻塞的说明
NCCL group 为什么反而要合在一起
ncclGroupStart() / ncclGroupEnd() 包围一组通信调用时,group 表示把这些调用作为一批提交。它与“每轮只准少数 peer 通信”不是同一个抽象。
想象两个 rank 都在同一条各自的 CUDA stream 上先执行 send,再执行 recv。若 send 的完成依赖对方的 recv,而 recv 又排在本地尚未完成的 send 后面,就形成循环等待。这是依赖关系问题,与交换机有没有拥塞无关。
NCCL 的点对点文档明确要求:需要并发推进的调用应合入同一个 group。两端都可以按如下方式表达交换:
1 | ncclGroupStart(); |
这是 NCCL 2.27 归档文档的 sendrecv 示例。peer 是对端,comm 是已有通信域,stream 是本地 CUDA stream;发送 buffer 由本 rank 产生,接收 buffer 由本 rank 分配,两端的发送与对应接收必须匹配计数和类型。group 让相关收发能够共同推进,并不创建一个更小的通信域,也不保证底层所有数据同时占用物理链路。NCCL 点对点通信文档
NCCL group 还有两项相关用途:单线程管理多个 GPU 时,避免第一张卡的调用阻止其他卡的调用被提交;聚合多个操作,减少启动开销。这里的优化方向可能是合并更多调用,而限流的方向是减少同时在途的工作,两者可以在不同层次配合。NCCL Group Calls
也不能把 ncclGroupEnd() 返回理解为 GPU 通信数据已经全部完成。普通情形还需要适当的 stream 完成依赖;对非阻塞 communicator,若返回 ncclInProgress,甚至还需先确认后台提交完成,再进行相关 CUDA 操作。主机返回、提交完成和设备完成是不同边界。同一文档的 Nonblocking Group Operation
按拓扑分组还能改变数据路径
此前的分批保留同样的最终数据与直接传输关系。按节点、交换域或网络 rail 组织的分层通信,还可能改变数据经过哪里、在哪里聚合和在哪里复制。
设四台机器每台有八张 GPU。先按目标机器整理数据,跨机传送后再在机内分发,可以利用机内与机间链路的性能差异。但如果原来每个源给每个目标的内容都不同,仅把小消息拼成大消息,并不会自动减少跨机器边界必须送达的有效字节;它主要改变消息粒度、路径和资源使用,可能增加打包、转发和临时 buffer。
MoE 有一个不同的机会:同一 token 的 hidden 向量可能需要送给同一目标节点上的多个专家。假设三个专家都在那台机器上,朴素逐专家跨机发送需要三份 hidden;若路由元数据足够,跨机发送一份,到目标节点后再复制给三个专家,就能减少重复跨机 payload。这里减少的是同一份内容的跨机复制次数,不是 all-to-all 分组的一般保证。专家返回的结果通常不同,combine 是否能减少跨机字节,还需检查何处允许做局部归约。
DeepSeek-V3 技术报告 §3.2.2 给出具体实例:它限制每个 token 的目标节点数,先经 IB 发送到目标节点内相同局部编号的 GPU,再通过 NVLink 转发到持有目标专家的 GPU。报告同时描述了发送、转发和接收的 warp 分工。该证据支持路由、拓扑和通信内核共同设计的机制,不能用来证明任意分组都有同样收益,也不能直接迁移成 Ascend UDMA 的性能结论。DeepSeek-V3 Technical Report,v2,§3.2.2
图 3 用两台机器、每台两张卡画出这条路径。节点内是一个分组维度,跨节点的相同局部编号是另一个维度;A0 同时属于节点内组 {A0,A1} 和跨节点组 {A0,B0}。这些组允许重叠。
这类方案有两种容易画混的顺序:图中是先跨机,再在目标机内转发;另一种是先在源机内按目标聚合,再跨机传输,必要时最后再分发。它们属于同一类分层思想,但不是一条相同的执行路径。画具体实现时,应按真实的数据搬运顺序选一种,而不是只画“机内快、机间慢”两个框。
它对阻塞的帮助来自两方面:把一些分发工作交给机内链路,以及让跨机接收与机内转发按 chunk 流水推进,减少“整批收完才开始转发”的等待。但分层没有自动规定 B0 最多接收几个外部发送者。如果许多源节点都向 B0 注入,而 B0 转发不及时,中转 buffer 一样会积压;跨机接收和机内转发两级都需要容量与进度约束。未到额度的数据留在上游,已就绪的块尽早进入下游,才形成受控流水。
拓扑分层还有一个常见反例:若所有跨机数据都压到一张代理 GPU 或一张网卡,原本可并行的带宽可能闲置,代理却成为瓶颈。分组映射应利用实际链路,不能只追求 rank 编号整齐。
现在可以把几个 group 放到同一张表里比较:
| group 的含义 | 改变的对象 | 主要收益条件 | 新成本或边界 |
|---|---|---|---|
| 独立通信域 | 参与者与数据交换范围 | 应用允许通信局部化 | 全局语义可能改变,跨域交换需另行补齐 |
| 调度分组、在途窗口 | 当前允许推进的 peer 或请求 | 全开并发引发资源压力 | 更多轮次;窗口过小会吃不满带宽 |
| 拓扑分层 | 传输路径、聚合与复制位置 | 能利用快链路、分散热点或去除重复跨域传输 | 打包、转发、额外存储与代理热点 |
| NCCL 调用 group | 一批提交和共同推进的调用 | 收发有并发推进要求,或启动开销显著 | 必须满足消息匹配、提交与完成依赖 |
拓扑图和流程图分别画什么
拓扑图回答“谁能向谁发送、共享什么资源”;流程图回答“什么时候允许发、完成后谁被放行”。只画静态的四个 group 和箭头,还无法证明接收端受到保护。
- 先画物理边界,再叠逻辑分组。标出节点、网卡或交换域,把 rank 放回实际位置。逻辑 group 可以跨节点或互相重叠;只有确认存在独立物理链路,才把两组画成独立 rail。
- 一幅拓扑只高亮一个阶段。注明组成员、发送方向、箭头代表单 rank 流还是一束组间流;再数每个接收 rank 和共享网卡的入向。数据去往不同 rank,不代表没有竞争同一网卡。
- 流程图画出放行条件。把“等待 credit → 预留资源 → 提交匹配通信 → 数据可见 → 消费/完成转发 → 安全释放 → 返还 credit”按所分析协议展开。省略的步骤要说明;不要用一个含糊的“完成”同时代指源 buffer 可复用和目标 buffer 可复用。
- 在等待处标出数据存放位置。无许可时是源 buffer 保留数据,还是已经占用了中转 buffer?等待的工作是否挡住其他 peer 的进度?能否继续处理其他就绪块?这些信息才能区分受控背压与队头阻塞。
判断“避免了什么阻塞”时,最可靠的是给出能检查的约束:某阶段每个目标最多 s 个源、某接收 buffer 最多 C 个未释放块、某块就绪后不必等其他目标的数据。单纯写“分组避免阻塞”,无法区分这三种机制,也无法发现不正确的跨轮推进。
怎样判断自己的分组有没有用
分组不能突破必要数据量与物理带宽决定的下界。在没有拥塞额外损失的理想系统里,如果一个接收端必须收到 64 MB、有效接收带宽为 16 GB/s,那么仅搬这些字节就至少需要约 4 ms;两者都按十进制计。分组改变不了这个算术,只可能避免实际时间被额外排队和控制开销进一步拉长,也可能因调度空隙而变慢。
对稠密的 64-rank 交换,如果每轮每个 rank 最多与四个非自身目的 rank 发送,完整覆盖 63 个目的 rank 至少需要 16 轮,最后一轮是三个。相比全开,这种安排降低同时活跃的逻辑传输数量,总计 64 × 63 条有向传输仍然存在。这只是均匀调度模型;真实 MoE 可能稀疏、变长,不能把轮数比例当成延迟或显存比例。
实际验证可以围绕以下步骤展开:
- 固定语义和工作量。固定硬件、拓扑、rank 映射、数据类型、发送量与实现版本。MoE 重放同一批路由记录,检查每个目标得到的数据和反向结果一致;只调度分组时,不能靠少发 token 获得“加速”。
- 分开并发与错峰。比较全开、同一并发窗口但目的地顺序相同、同一窗口且目的地错开;再单独评估 credit 或滑动窗口。这样才能区分减少请求数和消除接收热点的贡献。
- 扫描适当的窗口范围。例如 1、2、4、8、16 和全开,分别测试小消息、大消息、均匀流量与热点流量。窗口应足够填满传输管线,又不把有限资源推入低效区间;最优值需要测量。
- 测设备完成与整体关键路径。预热后测重复迭代,统计每轮最慢 rank 的完成时间,再对这些迭代求中位数和 P95/P99;同时记录吞吐和完整训练或推理步骤时间。仅测异步 API 的主机返回耗时会漏掉真正通信。
- 找对应的原因证据。结合硬件可用计数器看链路利用率、队列压力、ECN/PFC 或重传、发送提交开销、credit 等待、同步等待及计算重叠。PFC 只适用于相应网络,不能要求 NVLink 或其他互联也出现该指标。
Open MPI 5.0.7 的 Alltoall 提供了一个现成的实验入口:在确认 tuned 组件实际被选中后,用 coll_tuned_use_dynamic_rules=1、coll_tuned_alltoall_algorithm=4 选择 linear_sync,再扫描 coll_tuned_alltoall_max_requests。这些设置针对该版本对应算法,不能直接当作 NCCL、Alltoallv 或 MoonEP 的通用参数。Open MPI 调优文档
如果窗口减小后,队列压力和尾延迟下降、有效吞吐提高,才有依据认为原来的过量并发伤害了性能。如果吞吐下降而拥塞指标没有改善,应考虑原来并没有这个瓶颈。如果只有某个 rank 始终很慢,则还要检查路由倾斜、计算延迟和硬件异常;平均分组不会减少那个 rank 实际必须处理的数据。
回到最初的问题:all-to-all 分组的优势,是把请求的时间、目的地、数据路径与推进依赖组织成硬件能够高效执行的工作。它可以缓解拥塞、缩小部分等待的影响,也可以通过正确的 NCCL 调用分组避免特定死锁。但“分 group 就能防止通信阻塞”仍然太宽泛。判断一个实现时,最有用的追问是:它限制了哪项资源,哪条等待关系因此改变,代价又落在哪里?
参考资料
- MPI All-to-All 语义:用于界定最终数据布局,不代表某种网络执行顺序。
- Open MPI 5.0.7 调优文档与对应 Alltoall 源码:用于验证全开、pairwise 和有限请求窗口的区别。
- NCCL 2.27 点对点通信与Group Calls:用于验证共同推进、聚合提交和异步完成边界。
- NVIDIA 网络机制说明:仅用于路径拥塞、端点 incast 与 PFC 的机制区分,未采用其产品性能数字。
- DeepSeek-V3 Technical Report,arXiv v2:仅以 §3.2.2 支持 IB/NVLink 路由与通信协同设计,未把报告效果当作本文实测。
- MoonEP Group Design:仓库内的交互延伸阅读,包含固定版本 Ascend 移植实现的源码入口。